feat(pbr): optional threejs-materials backend via pymat.pbr Protocol (draft — refs #3)#30
Draft
feat(pbr): optional threejs-materials backend via pymat.pbr Protocol (draft — refs #3)#30
Conversation
- .typos.toml: add `metalness` and `metalnessMap` to extend-words. These are Three.js MeshPhysicalMaterial API keys; typos auto-rewrites them to `metallicity`/`metallicityMap` (not words) without this pin. Same failure class as the earlier Macor → Macro and Nd → And incidents from the bootstrap session. - .gitignore: exclude `examples/output/` — runtime output dir for the new examples/pbr_integration.py script. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements ADR-0002: add PBR integration as an optional extra so
py-materials can carry full MaterialX rendering data while physics-
only users stay lean.
Design
------
- `pymat.pbr.PbrSource` — runtime_checkable typing Protocol with
one method, `to_three_js_dict()`. Any conforming object can be
assigned to `Material.pbr_source`.
- Native lite `PBRProperties` dataclass gains `to_three_js_dict()`
so it satisfies the Protocol. Outputs Three.js MeshPhysicalMaterial
camelCase keys (color, metalness, roughness, transmission,
opacity, transparent, emissive, ior, clearcoat, normalMap,
roughnessMap, metalnessMap, aoMap) with defaults omitted.
- `Material` gains `pbr_source: Optional[PbrSource] = None` field
and `to_three_js_material_dict()` method. When `pbr_source` is
set, it takes precedence over `properties.pbr` (the lite native
backend) for rendering output.
- `[pbr]` optional extra pins `threejs-materials>=1.0.0`
(Bernhard's Apache-2.0 library, canonical PBR loader consumed
by ocp_vscode). Also added to the `all` extra.
- `from pymat.pbr import PbrProperties` is a conditional re-export
of `threejs_materials.PbrProperties` when the extra is installed,
so users can write one import and don't need to know about the
underlying library.
Usage
-----
Without the extra (physics-only path, default install):
from pymat import Material
steel = Material(
name="Steel", density=7.85,
pbr={"base_color": (0.75, 0.75, 0.77, 1.0), "metallic": 1.0},
)
steel.to_three_js_material_dict() # → lite backend output
With the extra (`pip install py-materials[pbr]`):
from pymat import Material
from pymat.pbr import PbrProperties
steel = Material(
name="Brushed Steel", density=7.85, formula="Fe",
pbr_source=PbrProperties.from_gpuopen("Stainless Steel Brushed"),
)
steel.to_three_js_material_dict() # → rich threejs-materials output
Tests
-----
- 7 new tests in tests/test_pbr.py covering:
- Protocol conformance (isinstance check via runtime_checkable)
- Native lite backend serialization (minimal + full field coverage)
- Material dispatch to lite vs rich backend (via a stub conforming
to the Protocol — no threejs-materials install required for the
base test suite)
- Non-conforming object rejection
- Full suite 140 passed / 11 skipped (was 133 / 11 before).
Documentation
-------------
- docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md
captures the design rationale, six options considered, and the
upgrade trigger (when a second PBR backend emerges, or if
threejs-materials's maintenance changes).
- examples/pbr_integration.py — runnable end-to-end demo. Prints
physics properties, emits Three.js JSON to stdout, writes JSON
to examples/output/ for downstream viewer consumption. Graceful
degrade when [pbr] not installed. Includes a --visual flag that
opens the Material in ocp_vscode (manual path; automated headless
ocp_vscode snapshot is tracked as a follow-up).
Refs: #3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The `visual_demo()` block using build123d + ocp_vscode belongs in the build123d fork's examples dir (which already ships `[ocp_vscode]` as an optional extra), not in py-materials itself. py-materials' example stays minimal: demonstrate the py-mat API with lite + rich backends and emit Three.js JSON, no build123d dependency. The full composition example lives at `gerchowl/build123d@feature/pymat-material-integration:examples/pbr_material_pymat.py`, which imports both libraries and optionally calls `ocp_vscode.show()`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
gerchowl
added a commit
to gerchowl/build123d
that referenced
this pull request
Apr 15, 2026
…mat#3) Evolution of the existing `material: str` attribute on `Compound` and `Solid` to also accept `pymat.Material` instances, per the feature request in MorePET/mat#3 from this project's user community. The existing str tag (used by STEP/STL exporters as an external tool label) stays accepted — this is a **type widening**, not a rename. Downstream code using `isinstance(shape.material, str)` keeps working; new code can do `isinstance(shape.material, pymat.Material)` to opt into the richer physics + PBR path. Changes ------- - `Compound.__init__`: `material: str | PymatMaterial | None = None` - `Solid.__init__`: same type widen - Both docstrings explain the dual meaning + point at the `build123d[materials]` extra - `pyproject.toml`: new `[materials]` optional extra pulling `py-materials[pbr]` from the MorePET/mat feature branch (git+https pin — to be replaced with a PyPI version pin once py-materials 2.2.0 ships the `pbr` extra on PyPI, before proposing this PR upstream to gumyr/build123d) - `all` extra now includes `[materials]` - TYPE_CHECKING-only import of `pymat.Material as PymatMaterial` in both `composite.py` and `three_d.py` — build123d has **no runtime dependency** on py-materials unless the user installs `build123d[materials]` API collision note ------------------ The existing `Compound.material` / `Solid.material` attribute is currently used by STEP/STL exporters as a free-form str tag for downstream tool metadata. The issue author ([MorePET/mat#3](MorePET/mat#3)) may or may not have been aware of this collision when proposing the `shape.material = Material(...)` API. The type widen preserves every existing usage while enabling the new path. If a stricter resolution is preferred (separate attribute, deprecation path), that's a conversation for the issue — this PR takes the minimum invasive route. End-to-end example ------------------ `examples/pbr_material_pymat.py` demonstrates the full flow: 1. Create a build123d Part (Box minus Cylinder) 2. Create a `pymat.Material` with physics (density, formula, thermal) and a rich PBR backend via `threejs_materials.PbrProperties.from_gpuopen(...)` 3. `part.material = steel` — single assignment, both consumers read from the same object 4. Print physics (density, molar_mass), geometry-derived mass (volume × density), and the Three.js MeshPhysicalMaterial dict 5. `--visual` flag calls `ocp_vscode.show(part)` for live rendering Verified end-to-end with a local py-materials install: === Physics properties (via py-materials) === density: 8.0 g/cm³ molar mass: 55.85 g/mol part volume: 21.99 cm³ part mass: 175.92 g === PBR rendering (Three.js MeshPhysicalMaterial dict) === { "color": [0.75, 0.75, 0.77], "metalness": 1.0, "roughness": 0.35 } Install ------- pip install build123d[materials,ocp_vscode] or for the lite path (physics + basic PBR scalars, no texture lib): pip install build123d py-materials # no [pbr] extra → uses the lite in-tree pymat PBRProperties Draft / exploratory ------------------- This branch and PR are exploratory — pending design direction on MorePET/mat#3 from @gumyr and @bernhard-42. Not ready to merge upstream until: - py-materials' `feature/3-pbr-protocol-integration` (MorePET/mat#30) lands on dev and ships as py-materials 2.2.0 on PyPI - The `materials` optional-dependency pin is switched from git+https to a PyPI version (`py-materials[pbr]>=2.2.0`) - Collision + type-widening approach is reviewed by @gumyr
…xtra
Graceful enhancement for existing downstream renderers that already
read `material.properties.pbr.<field>` directly, e.g. `ocp_vscode`'s
`_extract_materials_from_node()` in `show.py`.
## The problem
ADR-0002 added `Material.pbr_source` as the rich backend field.
`Material.to_three_js_material_dict()` dispatches to the rich source
when set. But existing ocp_vscode (and any other renderer that
reads the lite `properties.pbr` dataclass directly) can't see the
rich-backend data without a code change on their side.
## The fix
When `pbr_source` is set, `__post_init__` now calls
`_backfill_pbr_from_source()` which projects the rich backend's
`to_three_js_dict()` output onto the lite dataclass: color,
metalness, roughness, ior, emissive, transmission, clearcoat, plus
normal/roughness/metalness/ao maps.
Result: `material.pbr_source = PbrProperties.from_gpuopen(...)`
renders with MaterialX textures through existing ocp_vscode today,
**no adapter change on Bernhard's side required**. The rich source
still takes precedence in `to_three_js_material_dict()` for callers
that can handle extra fields (sheen, anisotropy, iridescence, etc.)
not present in the lite dataclass.
One-way copy at `__post_init__` only — not a live sync. Re-assigning
`pbr_source` re-runs the backfill. Fields without a lite counterpart
are dropped in the projection; the lossy subset is documented in the
updated ADR-0002.
## Also
- `[pbr]` extra now pulls `threejs-materials[materialx]>=1.0.0`
instead of `threejs-materials>=1.0.0`. The `[materialx]` sub-extra
brings the MaterialX SDK, without which `PbrProperties.from_gpuopen`
errors on first load ("materialx is not installed").
- 5 new tests in `TestPbrBackfill` covering: scalar backfill, texture
map backfill, adapter compatibility (simulates Bernhard's adapter
reading `properties.pbr.<field>`), no-op when pbr_source unset,
rich source still wins in dispatch.
- ADR-0002 updated with a dedicated "Backfill pattern" section.
Full suite: 145 passed / 11 skipped (was 140 / 11).
Refs: #3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
threejs_materials.PbrProperties.to_dict() returns a nested dict:
{
"id": "...", "name": "...", "source": "...", "url": "...",
"license": "...",
"values": {color: [...], metalness: ..., roughness: ..., ...},
"textures": {normal: "...", roughness: "...", ...},
}
not a flat Three.js MeshPhysicalMaterial dict. My first backfill
pass assumed the flat shape, which worked against stub backends in
the unit tests but failed at runtime against the real library with
an AttributeError on `to_three_js_dict`.
This change:
- Detects the shape at runtime via `isinstance(d.get("values"), dict)`
and picks `values` for scalars, `textures` for maps when nested.
Falls back to reading top-level keys when flat (so the native lite
`PBRProperties.to_dict()` flat output still works).
- Uses short-form map names (`normal`, `roughness`, `metalness`, `ao`)
that threejs-materials' PbrMaps dataclass emits, with a fallback
to the camelCase `normalMap`/`roughnessMap`/etc. for the flat
shape.
Verified end-to-end with a real threejs_materials.PbrProperties:
>>> from threejs_materials import PbrProperties
>>> rich = PbrProperties.create('Steel', color=[.91,.91,.88], metalness=1, roughness=.08)
>>> steel = Material(name='Steel', pbr_source=rich)
>>> steel.properties.pbr.base_color
(0.91, 0.91, 0.88, 1.0)
>>> steel.properties.pbr.metallic
1.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
gerchowl
added a commit
to gerchowl/vscode-ocp-cad-viewer
that referenced
this pull request
Apr 15, 2026
When a `pymat.Material` carries a rich `pbr_source` (a
`threejs_materials.PbrProperties` instance, typically set via
`material.pbr_source = PbrProperties.from_gpuopen(...)`),
`_extract_materials_from_node` now bypasses the field-by-field
projection through the lite `pymat.properties.pbr` dataclass and
uses the rich source directly.
## Why
`py-materials`' lite `PBRProperties` dataclass has no
`base_color_map` / albedo / color texture field. Before this
change, the `is_pymat(node.material)` branch would call
`PbrProperties.create(..., normal_map=..., roughness_map=...,
metalness_map=..., ao_map=...)` and silently drop the color
texture. For materials whose visual identity lives in the color
map (wood, bricks, tiles, stone — most non-metal MaterialX
libraries on matlib.gpuopen.com, polyhaven.com, ambientcg.com),
this made them render as flat white meshes even with a valid
`pbr_source` assigned.
Verified locally with `gpuopen/Ivory Walnut Solid Wood`:
- `part.material = PbrProperties.from_gpuopen("Ivory Walnut Solid Wood")`
renders with full wood grain (takes the
`isinstance(node.material, PbrProperties)` fast path).
- `part.material = pymat.Material(..., pbr_source=<same PbrProperties>)`
previously rendered as flat white (lossy field copy dropped the
color map). With this change, it now renders identically to the
direct assignment.
## Design
The fix is 1 conditional + 2 lines: prefer `pbr_source` when set,
fall back to the field-by-field copy when not. Materials without
a rich source (TOML-authored, lite-only) continue to work exactly
as before — backward compatible.
The rich source path also picks up every Three.js
MeshPhysicalMaterial scalar that py-materials' lite dataclass
doesn't model (sheen, anisotropy, iridescence, dispersion,
clearcoat maps, specular intensity/color, thickness, displacement,
specular tinting). These were previously unreachable through the
pymat path.
## Context
Part of an ongoing collaboration between `gumyr/build123d`,
`MorePET/mat` (py-materials), and `bernhard-42/threejs-materials`
to make `shape.material = pymat.Material(...)` the canonical
carrier type in build123d (both physics values and PBR rendering).
- `MorePET/mat#3` — design discussion
- `MorePET/mat#30` — py-materials PR adding `Material.pbr_source`
- `gumyr/build123d#1276` — build123d PR widening
`Compound/Solid.material` type
- `MorePET/mat`'s ADR-0002 — architectural rationale for the
Protocol + optional-extra approach
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The lite `PBRProperties` dataclass previously had fields for
`normal_map`, `roughness_map`, `metallic_map`, and
`ambient_occlusion_map` but no `base_color_map`. This meant the
PRIMARY texture channel — the albedo/diffuse map that carries
most of the visual identity for wood, bricks, tiles, stone,
and most non-metal PBR materials — was silently dropped during
backfill from a rich `pbr_source`.
Metals accidentally survived because their visual character
comes from `metalness=1` + `roughness` + environment reflection,
not an albedo map. Non-metal materials rendered as flat white.
Changes:
- `PBRProperties.base_color_map: Optional[str] = None` (new field).
- `PBRProperties.to_dict()` emits it as `map` (Three.js's name
for the color channel is plain `map`, not `colorMap` / `albedoMap`).
- `_backfill_pbr_from_source()` reads it from both the nested
`{textures: {color: ...}}` shape (threejs_materials v1) and
the flat `{map: ...}` shape (native lite output).
- New test `test_backfill_handles_nested_threejs_shape` captures
a realistic fixture matching threejs_materials' real output
(verified against the live library at v1.0.4).
Note: this fix alone isn't sufficient for ocp_vscode rendering —
Bernhard's `_extract_materials_from_node` reads texture fields
from `pbr.normal_map`/etc. but never from an albedo field, so
the map never makes it to `PbrProperties.create()`. The complete
fix is the companion PR on bernhard-42/vscode-ocp-cad-viewer#228
which bypasses the lossy field-copy entirely when `pbr_source` is
set. This base_color_map field still lands for completeness and
for any future downstream consumer that does read the lite
dataclass directly.
Refs #3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This was referenced Apr 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft — exploratory
Opened as a draft to give @gumyr and @bernhard-42 a concrete implementation to react to on #3. Do not merge until we have their input. The direction matches Option II from the analysis posted on #3 — optional extra, not absorption — but this is implementation only; actual integration with build123d and ocp_vscode comes after the collaboration direction is settled.
What this implements
See ADR-0002 (
docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md) for the full design rationale. TL;DR:Not in this PR
Side incident: typos hook auto-rewrote
metalness→metallicitySeparate small commit on the same branch: the `typos` pre-commit hook (same one that hit us with `Macor → Macro` and `Nd → And` during the bootstrap session) decided `metalness` — the actual Three.js `MeshPhysicalMaterial` API key — was a typo for `metallicity` (not a word). Silently corrupted 4 files. Hardened `.typos.toml` with `metalness` + `metalnessMap` in extend-words. Swap to a less aggressive spell-checker is worth considering as a follow-up.
Test plan
Refs
🤖 Generated with Claude Code